Skip to content

feat(policies): expiry + scope lifecycle controls (token-optimization R2) - #1893

Merged
simple-agent-manager[bot] merged 13 commits into
mainfrom
sam/continue-complete-policy-lifecycle-e3tm8a
Aug 24, 2026
Merged

feat(policies): expiry + scope lifecycle controls (token-optimization R2)#1893
simple-agent-manager[bot] merged 13 commits into
mainfrom
sam/continue-complete-policy-lifecycle-e3tm8a

Conversation

@simple-agent-manager

Copy link
Copy Markdown
Contributor

Summary

R2 of the token-optimization program (research: library /engineering/research/token-optimization-research.md §3.3 and §8/R2; idea 01M0QGZQ15WE9DDQENGV6S9XZ5). Follows R1 (#1891), which removed the duplicated knowledgeContext/policyContext arrays.

getActivePolicies injects every active policy into every session, unranked and with no shelf life. This project has 81 active policies against a cap of 100, and several of them describe work that finished weeks ago — "Use Codex 5.5 High Chat VMs for current reliability workflow" names a 2026-08-21 workstream that is over. They will keep loading into every future session's opening turn until a human notices. There was no way for an agent capturing a genuinely temporary constraint to mark it as temporary: add_policy had no expiry and no scope, so every policy was implicitly permanent.

Two additive columns fix that:

  • expires_at INTEGER (nullable) — the filter. NULL means "never expires", which is exactly today's behaviour, so all 81 existing rows are unaffected by construction.
  • scope TEXT NOT NULL DEFAULT 'always' ('always' | 'task') — the discriminator, backfilled to 'always'.

The load-bearing part is that these two are coupled: a task-scoped policy MUST carry an expiresAt, enforced at every write boundary. That is the actual mechanism that stops a one-shot constraint from becoming permanent — an agent cannot mark a policy as tied to a specific workflow without also giving it a shelf life.

Filtering happens at read time onlyactive = 1 AND (expires_at IS NULL OR expires_at > ?). No sweep, no cron, no alarm (rule 47: a WHERE clause answers this for free). The row is deliberately retained and stays active, so get_policy, list_policies, and the Policies tab can still show a human that a policy existed and when it lapsed. Only the agent-injection read filters.

Implementation notes for reviewers

Three write boundaries, not two. The original enumeration listed MCP and REST and missed durable-objects/sam-session/tools/add-policy.ts — the SAM orchestrator's own add_policy. Left unfixed, every policy created from an orchestrator surface would have been permanently non-expiring: the exact failure this feature exists to remove, reintroduced through the one door nobody counted. All three now call one shared validatePolicyLifecycle, and the DO re-checks the invariant against freshly-read state immediately before the write — the choke point a future fourth writer cannot bypass (rules 44/51/61).

updatePolicy uses an explicit !== undefined check for expiresAt, not ??. null is a meaningful value here — it clears an expiry — and updates.expiresAt ?? existing.expiresAt would silently read "clear this expiry" as "leave it alone".

The scope/expiry invariant is validated against the merged post-write state, not the patch alone. Otherwise {scope:'task'} on a policy that already has an expiry would be wrongly rejected, and {expiresAt:null} on a task-scoped policy would wrongly succeed and resurrect a permanent one-shot policy.

Migration 034 is strictly additive — two ALTER TABLE ADD COLUMN, no recreation, no DROP. A Durable Object has no D1-style time-travel recovery, so a drop-and-restore here would be unrecoverable (rules 31/63).

Rule 63 enumeration (every query reading project_policies, and whether it is an authorization predicate) is recorded in the task file. None of them is: project scoping here is structural — the ProjectData DO is the project (idFromName(projectId)), so there is no project_id column that a widening could drop from a WHERE. Rule 63's failure mode does not apply, but the enumeration is recorded because the rule requires it.

A quality gate that was green for the wrong reason

Review turned up something worth calling out separately, because it is not really about this feature.

scripts/quality/check-do-migration-safety.ts extracted SQL by matching backtick and single-quoted strings only. Migration 034's second statement is double-quoted — necessarily, because it embeds DEFAULT 'always'. So that statement was never handed to the danger checks at all. The file reported PASS without it ever being inspected.

Reproduced directly:

sql.exec('ALTER TABLE project_policies ADD COLUMN expires_at INTEGER');
sql.exec("DROP TABLE project_policies");
    → DROP TABLE visible to scanner: false

Rule 31 describes this gate as one that "cannot be bypassed". A DROP TABLE on a Durable Object is unrecoverable, and the choice of quote character was silently deciding whether it got checked. The extractor now covers all three string forms, each pattern anchored to its own delimiter so a quote inside one string cannot pair with a delimiter from a neighbouring statement. Six regression tests added, proven discriminating.

This is a latent hole in a data-loss gate, unrelated to policies — it just happened to be found here because this migration was the first to use double quotes.

Not fixed — tracked instead

Expired rows are deliberately retained and no longer count toward the per-project cap, so nothing bounds total row growth (flagged independently by security-auditor and performance-reviewer, both MEDIUM). A naive total ceiling is worse than the growth it prevents: removePolicy is a soft delete, so a project that reached the ceiling could never write a policy again — a slow growth problem turned into a permanent write lockout. Sizing a ceiling requires first deciding a retention story. Documented at the cap site and tracked in tasks/backlog/2026-08-23-policy-row-retention-bound.md (rule 42 — tracked, not silent).

Part 2 — production data cleanup

Executed after this merges and deploys, using remove_policy (reversible deactivation, never a destructive delete). The deactivated ids + titles + justifications are reported in a follow-up comment on this PR and via update_task_status.

Validation

  • pnpm lint (via pnpm check:fast — 0 errors; 3 pre-existing react-hooks/exhaustive-deps warnings untouched by this PR)
  • pnpm typecheck — 19/19 tasks pass
  • pnpm test
  • Additional validation run (if applicable)
  • If this PR changes candidate selection for a sweep/cron/alarm loop — N/A: no sweep/cron/alarm changed. Expiry is a read-time WHERE conjunct on an existing query; deliberately no new control loop (rule 47). git diff --stat shows zero changes under apps/api/src/scheduled/ and no alarm( additions.

Test totals, reconciled against the pre-change baseline (rule 02 — a green count is not a green suite):

Suite Result
apps/api pnpm test 599 files / 8135 tests pass, 0 collection failures. Baseline before my review fixes was 599 / 8130 — up exactly 5, which is exactly the 5 tests I added.
apps/api pnpm test:workers (real workerd + real DO SQLite + real migration chain) 54 files / 690 tests pass
packages/shared 33 files / 613 tests pass
apps/web 290 files / 3456 tests pass
Playwright agent-context-audit.spec.ts 60 pass
pnpm quality:do-migration-safety PASS

I checked per-file collection status, not just assertion counts: the first API run (before building providers/cloud-init) showed 115 files failing to import, and the file total stayed at 599 across both runs — that is how I confirmed nothing silently vanished from the suite.

Discriminating proofs

Every new guard was verified by deleting it and confirming exactly the intended tests go red, then restoring:

Guard removed Went red Stayed green (control)
Expiry conjunct in APPLIES_NOW_SQL → tautology exactly 2: "excludes an expired policy…", "excludes expired policies from the per-project cap" all 15 others, including the null-expiry control
Double-quote extraction in the migration scanner exactly the 4 double-quote tests the 7 pre-existing extractor tests
MCP pre-read made unconditional exactly the new I/O-budget test 19 others
validatePolicyLifecycle call in the SAM-session writer 3 of 5 new cases the 2 guarded by the inline type-shape check
…plus the inline scope/numeric guards all 5

Staging Verification (REQUIRED for all code changes — merge-blocking)

  • Staging deployment green
  • Live app verified via Playwright
  • Existing workflows confirmed working
  • New feature/fix verified on staging
  • Infrastructure verification — N/A: no infra changes. No changes to packages/cloud-init/, packages/vm-agent/, DNS, TLS, or scripts/deploy/. (scripts/quality/ is a CI check, not provisioning infrastructure.)
  • Mobile and desktop verification notes added for UI changes

Staging Verification Evidence

Deploy run 32673192429success. GET https://api.sammy.party/health{"status":"healthy"}.

Verified against live staging (app.sammy.party / api.sammy.party), authenticated via POST /api/auth/token-login with SAM_PLAYWRIGHT_PRIMARY_USER → 200. Project 01KTKXZ4ZZAT6MJFXRW1ZTQ7RB (hono).

1. Write boundary — every rejection is live, with the real messages:

Request Result
scope:'task', no expiresAt 400 — "a task-scoped policy must set expiresAt so it cannot outlive the work it was captured for"
expiresAt in the past 400 — "expiresAt must be in the future — use remove_policy to deactivate a policy immediately"
expiresAt beyond the horizon 400 — "expiresAt must be within 31536000000ms of now" (365 days — the configurable limit is live)
scope:'forever' 400 — "scope must be one of: always, task"
scope:'task' + valid expiresAt 201
standing policy, no expiresAt 201

2. Merged-post-write-state validation is live (not just patch-level):

Request Result
PATCH {expiresAt: null} on a task-scoped policy 400 — correctly refuses to resurrect a permanent one-shot policy
PATCH {scope:'task', expiresAt} 200
PATCH {scope:'task'} on a standing policy with no expiry 400

3. The read filter — an expired policy is genuinely not injected into a real agent session.

This is the acceptance criterion that matters, so I exercised the real path rather than inferring it. I created a task-scoped policy expiring in 90s plus a standing control, let the expiry lapse, then started a real chat session on staging (cf-container, "Claude Code Chat" profile) and asked the agent to call get_instructions and report which of the two it could see:

LIFECYCLE_PROBE_PRESENT=no     ← the expired policy was NOT injected
STANDING_PROBE_PRESENT=yes     ← the standing control WAS injected

The control is what makes the absence meaningful. My first attempt failed it — I had told the agent "do not call any tool", so it never fetched get_instructions and answered no to both. Without the control that would have read as a pass. Corrected prompt, re-ran, got the result above.

4. Retention — an expired policy stays inspectable: GET /policies/:id200 with scope:'task', active:true, lapsed:true; still present in list_policies.

5. UI, both viewports, live staging (.tmp/staging/03-policies-{desktop,mobile}.png): the expired card renders constraint + active + task-scoped + expired badges and an "Expired Aug 23, 2026" footer; the standing control card renders neither lifecycle badge nor an expiry footer. No horizontal overflow at 375×667 or 1280×800. Zero console errors.

A first-run setup wizard intermittently covered the page during this run. It silently swallowed every locator, and the "no badge on the standing card" assertions passed against a page the user never sees — the rule-62 trap. Caught by the liveness assertion, then fixed by dismissing the wizard and retrying; I also tightened liveness from "the word Policies appears" (which matched hidden nav text) to "policy cards actually rendered".

6. Regression sweep: dashboard loads and renders; project list returns 15 projects; project navigation, Agent Context tabs, and the policy filter all work; zero console errors across every page visited.

7. Cleanup: both probe policies deactivated, both test workspaces deleted ({"success":true}), and staging D1 confirms nodes are 193/193 deleted — zero VMs at rest.

UI Compliance Checklist (Required for UI changes)

  • Mobile-first layout verified
  • Accessibility checks completed — expiry/scope state is conveyed by text (expired badge, "Expired <date>" footer), not by colour alone; colour is supplementary emphasis
  • Shared UI components used — reuses the existing Badge and the same semantic tokens (bg-info-tint/text-info-fg, bg-warning-tint/text-warning-fg) already used by the category badges. No new dependency, no bundle impact; AgentContextPage is already React.lazy-loaded.
  • Playwright visual audit run locally

The audit covers the standing / live-task-scoped / expired / worst-case matrix at 375×667, 390×844, 768×1024 and 1280×800, using assertNoOverflow from audit-helpers.ts (which includes the clipped-overflow walk — rule 56), not a hand-rolled documentElement.scrollWidth check.

Review caught that the worst case was split across two cards: pl4 carried the 200-character unbroken title but was not expired, so "longest title" and "most badges" never met on one card. pl4 is now the genuine worst case — 200-char title carrying every badge (category + active + task-scoped + expired) — and both viewport tests assert the badges on that card rather than page-wide (page-wide would now be a strict-mode violation, not a pass).

Screenshots were opened and checked, not merely produced (rule 62): the four viewport sizes produce visibly different files (286KB / 375KB / 691KB / 723KB), and the 375px capture confirms the standing-policy control renders with no lifecycle badge while the task-scoped card shows the badge and its title wraps cleanly.

End-to-End Verification (Required for multi-component changes)

  • Data flow traced from user input to final outcome with code path citations
  • Capability test exercises the complete happy path across system boundaries
  • All spec/doc assumptions about existing behavior verified against code
  • If any gap exists between automated test coverage and full E2E, manual verification steps documented below

Data Flow Trace

1. Agent calls add_policy with scope:'task' + expiresAt
   → apps/api/src/routes/mcp/policy-tools.ts:handleAddPolicy()
   → parseLifecycleParams() (type shape) → validatePolicyLifecycle() (the invariant)

2. Service layer threads the two new params
   → apps/api/src/services/project-data-policies.ts:createPolicy()

3. DO RPC
   → apps/api/src/durable-objects/project-data/index.ts:createPolicy()

4. Pure SQL — final server-side guard, then INSERT
   → apps/api/src/durable-objects/project-data/policies.ts:createPolicy()
   → cap COUNT uses APPLIES_NOW_SQL so expired rows do not consume headroom

5. Later session opens and calls get_instructions
   → apps/api/src/routes/mcp/instruction-tools.ts:handleGetInstructions()
   → projectDataService.getActivePolicies()
   → policies.ts:getActivePolicies() — WHERE active = 1 AND (expires_at IS NULL OR expires_at > ?)
   → an expired policy is NOT returned, so it costs zero tokens

6. Surviving policies render, with the annotation
   → instruction-tools.ts:formatPolicyLifecycle() → formatPolicyDirectives()
   → "- **Title** (task-scoped, expires 2026-09-30) (id: <full uuid>): content"

7. Human sees why a policy stopped applying
   → row-schemas/policies.ts:parsePolicyRow() → listPolicies()
   → apps/web/src/pages/AgentContextPage/PoliciesTab.tsx — task-scoped / expired badges

The R1 merge point is step 6. R1 moved the policy id inline into the rendered line; this PR inserts the lifecycle annotation before it. The pre-existing "renders ids in FULL" test lives in an untouched sibling file (mcp-instruction-payload-dedup.test.ts) and was re-run: 14/14 pass. When formatPolicyLifecycle returns '' — which it does for every policy without an expiry, i.e. all 81 today — the line collapses back to byte-identical pre-change output.

Untested Gaps

The DO's fresh-read guard is not exercised by two genuinely concurrent updatePolicy RPCs. This is not the rule-45 class of bug: the DO's read → guard → write critical section contains no await, so it completes within one JS turn and two RPCs to the same DO instance cannot interleave across it. An explicit Promise.all test would be documentation rather than a correctness guard.

Part 2 (production policy deactivation, acceptance criterion 8) is by design not actionable until this deploys. Results reported in a follow-up comment.

Post-Mortem (Required for bug fix PRs)

This is a feature PR, but it carries one genuine bug fix — the migration-safety gate — so the post-mortem covers that.

What broke

pnpm quality:do-migration-safety reported migrations.ts: PASS while never inspecting one of the statements in the file. Any DROP TABLE, DELETE without WHERE, or UPDATE without WHERE written in a double-quoted string would have shipped through a green CI gate. On a Durable Object that is unrecoverable — there is no D1-style time travel.

Root cause

extractSqlFromTypeScript (scripts/quality/check-do-migration-safety.ts) matched only two of JavaScript's three string forms: backtick and single-quoted. Double-quoted strings were never extracted, so they were never scanned. Double quotes are not an exotic choice — they are the natural choice for a statement that embeds a single-quoted SQL literal, which is why migration 034 (… DEFAULT 'always') was the first migration to trip it.

Class of bug

A checker whose "pass" is the absence of input rather than the absence of danger — the same shape as rule 02's "a green test count is not a green suite" and rule 53's "silence is not success". A linter run on a path that matched no files, a coverage gate over an empty file set, and a SQL scanner that skipped a quoting style all report identically to a genuine pass. It is also a validated-input bug: the extractor silently defined what "all the SQL in this file" means, and nothing checked that definition against reality.

Why it wasn't caught

The gate's own test suite tested the checker's verdict on the real codebase (expect(result).toContain('PASS')) and re-implemented its own regex scans over the migration files — but never tested the extractor against the string forms it claims to cover. Every test agreed with the extractor's blind spot because every test shared it. No test had ever fed the checker a known-dangerous statement and demanded it be caught.

Process fix included in this PR

  • scripts/quality/check-do-migration-safety.ts — extractor now covers all three string forms, each pattern anchored to its own delimiter, with a comment explaining that a quoting style must never be able to bypass a gate rule 31 calls unbypassable. Exported for direct testing.
  • scripts/quality/check-do-migration-safety.test.ts — six tests that feed the extractor each string form directly, including the exact shape of migration 034 and an end-to-end "a DROP TABLE hidden in a double-quoted statement is still caught". Verified discriminating.
  • apps/api/src/durable-objects/migrations.ts — the migration-034 comment warning authors about quoted SQL in prose updated; it described the old backtick-only behaviour.

The generalisable lesson — test the checker by giving it something it must catch, not only by checking that it likes the current codebase — is already stated in .claude/rules/02-quality-gates.md ("A Green Test Count Is Not A Green Suite", which explicitly extends to "schema checks that skip when the input is unparseable"). This is that rule's first concrete instance in the migration-safety tooling, so the fix is the test, not a new rule.

Post-mortem file

This section, plus tasks/active/2026-08-23-policy-lifecycle-controls.md.

Specialist Review Evidence (Required for agent-authored PRs)

  • All local reviewers completed and findings addressed before merge
  • If any reviewer did NOT complete: needs-human-review label added and merge deferred to human — N/A, all ten completed

All findings addressed in c06642e8a unless noted.

Reviewer Status Outcome
cloudflare-specialist ADDRESSED 1 HIGH: migration-safety scanner never inspected double-quoted SQL — gate green for the wrong reason. Fixed + 6 discriminating regression tests. 1 MEDIUM (broad err.message→400 catch) not changed: matches the existing convention in deployment-environments.ts / gcp.ts / chat-comment-directives.ts and is not introduced here. Independently confirmed migration 034 is correctly sequenced after 033 and that ADD COLUMN … NOT NULL DEFAULT is proven against real workerd.
test-engineer ADDRESSED No CRITICAL/HIGH. Independently re-ran the DO discriminating proof and the untouched "renders ids in FULL" test (14/14 — confirms the R1 template merge is safe). 1 MEDIUM: the SAM-session writer covered 2 of the edge cases MCP/REST cover; added 4 more, proven discriminating against both the shared validator and the inline guards.
task-completion-validator ADDRESSED PASS on all six checks; independently reproduced the discriminating expiry proof rather than trusting the task file's claim. Criterion 8 correctly pending (post-deploy). 1 MEDIUM: task file recorded 2 of 3 writers; now records all three and the near-miss.
security-auditor ADDRESSED No CRITICAL/HIGH. Authorization, error-message leakage, input bounds (Number.isFinite/isInteger/future/maxExpiryMs — no new Date() overflow hazard), and logging all PASS. 1 MEDIUM unbounded row growth → tracked in tasks/backlog/2026-08-23-policy-row-retention-bound.md with the reason a naive ceiling was rejected. 2 LOW noted, both pre-existing patterns.
performance-reviewer ADDRESSED No CRITICAL/HIGH. Mutation budget OK (4 DO RPCs worst case vs ≤12); zero added tokens for standing policies confirmed; no new sweep; zero bundle impact. 1 MEDIUM test gap: MCP update_policy lacked the REST route's I/O-budget regression — added, proven discriminating. The "move validation into the DO to drop 2 RPCs" suggestion was assessed and not taken: the race it closes is not exploitable (the DO guard already catches the only harmful merge), so it is an optimization, not a correctness fix.
ui-ux-specialist ADDRESSED No CRITICAL/HIGH. Design tokens and non-colour-only status PASS. Fixed the worst-case fixture split described above. Flagged that PolicyCard reads Date.now() at render with no polling, so a card left open across the expiry moment shows stale state until the next render — accepted for a surface the page itself labels "Instruction-only until platform enforcement exists".
doc-sync-validator ADDRESSED 2 MEDIUM, both fixed here per rule 01. The task file's "no public doc describes this" claim was wrong: architecture/overview.md documents formatPolicyDirectives() output — now updated with the annotation format and read-time-filter semantics. CLAUDE.md Recent Changes entry added. Confirmed the SAM-CLI OpenAPI contract has no /policies route, so no regeneration is needed.
architecture-reviewer PASS No CRITICAL/HIGH. Independently enumerated every writer (3 create, 2 update; only migrations.ts and policies.ts touch the table by name — no raw-SQL bypass) and confirmed all funnel through the DO choke point. Confirmed scope is consumed, not write-only (rule 57). 1 MEDIUM (duplicated type-shape parsing) judged structurally justified — the three boundaries have incompatible error-return conventions and this mirrors the pre-existing pattern for category/title/confidence in the same files.
constitution-validator PASS No Principle XI violations. DEFAULT_POLICY_MAX_EXPIRY_MS + POLICY_MAX_EXPIRY_MS follow the established pattern and the override reaches all three write boundaries (verified behaviourally). 'always' and the date formatting correctly categorised as enum/display, not config.
env-validator PASS POLICY_MAX_EXPIRY_MS wired identically to its five POLICY_* siblings; single resolver, no ad-hoc reads; correctly absent from deploy plumbing, matching existing convention. GH_/GITHUB_ N/A.

Exceptions (If any)

  • Scope: total project_policies row growth is not bounded by this PR.
  • Rationale: a naive total ceiling would permanently lock a project out of writing policies, because removePolicy is a soft delete. Bounding growth needs a retention/hard-delete design first.
  • Expiration: tracked in tasks/backlog/2026-08-23-policy-row-retention-bound.md.

Agent Preflight (Required)

  • Preflight completed before code changes

Classification

  • external-api-change
  • cross-component-change
  • business-logic-change
  • public-surface-change
  • docs-sync-change
  • security-sensitive-change
  • ui-change
  • infra-change

External References

N/A: no external API involved. This is entirely internal — SQLite DDL against Cloudflare Durable Object storage plus SAM's own MCP/REST surfaces. SQLite's ALTER TABLE ADD COLUMN constraints were verified against ~10 prior migrations in the same file that already use the NOT NULL DEFAULT '<literal>' idiom successfully against real workerd, and confirmed by the real-DO test run rather than assumed.

Codebase Impact Analysis

  • packages/sharedtypes/policy.ts (POLICY_SCOPES, PolicyScope, isPolicyScope, new fields on 3 interfaces), constants/policies.ts (DEFAULT_POLICY_MAX_EXPIRY_MS, maxExpiryMs, the shared validatePolicyLifecycle)
  • apps/api — DO migration 034; project-data/policies.ts, project-data/index.ts, row-schemas/policies.ts; services/project-data-policies.ts; MCP policy-tools.ts + tool-definitions-policy-tools.ts + instruction-tools.ts; REST routes/policies.ts + schemas/policies.ts; sam-session/tools/add-policy.ts; env.ts
  • apps/webAgentContextPage/PoliciesTab.tsx (display-only badges + footer)
  • apps/wwwarchitecture/overview.md
  • scripts/qualitycheck-do-migration-safety.ts (+ tests)

Documentation & Specs

  • apps/www/src/content/docs/docs/architecture/overview.md — documented the lifecycle annotation in the get_instructions bootstrap payload and the read-time filter semantics
  • CLAUDE.md — Recent Changes entry
  • tasks/active/2026-08-23-policy-lifecycle-controls.md — research, rule-63 query enumeration, three-writer enumeration, checklist, acceptance criteria
  • tasks/backlog/2026-08-23-policy-row-retention-bound.md — new, tracks the deferred growth bound

Constitution & Risk Check

Principle XI (No Hardcoded Values): DEFAULT_POLICY_MAX_EXPIRY_MS (365 days) with a POLICY_MAX_EXPIRY_MS override, threaded through resolvePolicyLimits and reaching all three write boundaries. Registered in env.ts alongside the five existing POLICY_* vars.

Principle XIII (Fail Fast): every write boundary fails closed on a task scope with no expiry, and the DO re-checks against freshly-read state as the final guard.

Risks and tradeoffs:

  1. A wrong expiry silently drops a policy from agent context. Mitigated by bounding expiresAt to the future and within maxExpiryMs, by retaining the row so a human can see it lapsed, and by surfacing expired in the UI. Accepted: this is the feature working as intended.
  2. Migration risk. Additive only; both defaults reproduce pre-migration behaviour exactly, so all 81 existing rows are unaffected by construction. Verified against the real migration chain in workerd.
  3. Unbounded row growth. Accepted and tracked — see Exceptions.
  4. The R1 merge conflict. Three overlapping spans in instruction-tools.ts, resolved; the untouched full-uuid test re-run as the check.

@codspeed-hq

codspeed-hq Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing sam/continue-complete-policy-lifecycle-e3tm8a (5f82e32) with main (85d69a8)

Open in CodSpeed

raphaeltm and others added 11 commits August 24, 2026 08:22
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Policies were injected into every session forever with no shelf life, so
one-shot workflow policies ('use profile X for the 2026-08-21 wave') kept
loading into every future session long after the work finished.

- DO migration 034: additive ALTER TABLE ADD COLUMN for expires_at and scope.
  Defaults (NULL / 'always') reproduce today's behavior exactly, so every
  existing policy is unaffected.
- getActivePolicies filters expired at read time. No sweep or alarm (rule 47);
  getPolicy and listPolicies deliberately keep returning expired rows so a
  human can still see why a policy stopped applying.
- scope='task' requires expiresAt, enforced by one shared validator used by
  both the MCP and REST write boundaries (rule 24) plus a final DO-level guard.
- The per-project cap counts only policies that still apply, so inert expired
  rows cannot consume cap headroom.
- get_instructions annotates expiring policies and now tells agents to give
  one-shot policies an expiry when capturing them.

Co-Authored-By: Claude <noreply@anthropic.com>
…ite boundaries

- policy-do.test.ts: expiry predicate against real DO SQLite via the real
  migration chain. Proven discriminating 2026-08-23 — deleting the expiry
  conjunct turns exactly the two expired-policy tests red while the
  null-expiry backward-compat control stays green.
- Guard tests use runInDurableObject rather than the RPC stub: a stub-level
  rejection is also surfaced by the pool as an unhandled rejection and fails
  the run even when the assertion passes.
- Shared validatePolicyLifecycle unit tests: boundary, horizon, scope enum.
- MCP + REST tests prove BOTH write boundaries reject a task-scoped policy
  with no expiry, and that an update is validated against the merged
  post-write state rather than the patch alone.
- get_instructions tests assert the expiry annotation actually reaches the
  rendered directives, with a control proving standing policies stay unannotated.

Also removes stray {} arguments from three pre-existing createPolicy test
calls — that argument position is now scope, so they would have bound an
object into SQLite.

Co-Authored-By: Claude <noreply@anthropic.com>
…task checklist

Co-Authored-By: Claude <noreply@anthropic.com>
The suite was failing 12 of 13 tests on origin/main, independent of this
branch (verified by reverting these changes and re-running).

Root cause: the spec mocked GET /api/credentials as {credentials: []}, but
listCredentials() returns a bare CredentialResponse[]. OnboardingChecklist
passes that straight to hasByocComputeCredential, which calls .some() on it,
so the app threw 'e.some is not a function' and the ErrorBoundary replaced
the entire page — every test then failed on its first locator regardless of
what it asserted. The two endpoints genuinely have different shapes, so the
exact-path check has to come before the prefix check.

Also mocks /api/report-issue/config and /api/config/vapid-public-key, which
the app shell fetches on every authenticated page and the spec predates.

Co-Authored-By: Claude <noreply@anthropic.com>
- Thread scope/expiresAt through the SAM-session add_policy tool, the third
  production writer of createPolicy (wired into both the sam-session and
  project-agent tool registries). Enumerating only the MCP and REST callers
  missed it, so every policy created from the orchestrator surfaces was
  permanently non-expiring — the exact failure this feature exists to prevent.
- REST PATCH now maps the DO guard rejection to a 400 instead of letting a
  plain Error become an opaque 500 that also lands in /admin/errors.
- Migration comment no longer uses backticks: the migration-safety scripts
  test extracts every backtick literal in that file and validates it as SQL,
  so inline-code prose failed the CI gate.
- Cap-count test now drives the real cap boundary via runInDurableObject
  instead of re-asserting the read filter; it previously passed even with the
  COUNT query reverted to filter on active alone.
- Rejection-path tests added for both write boundaries.
- Removed a scratch diagnostic Playwright spec that was committed by mistake.

Co-Authored-By: Claude <noreply@anthropic.com>
The audit suite still timed out on every click after the credentials-shape
fix: useSetupStatus computes isComplete = hasAgent && hasCloud && hasGitHub,
and when that is false OnboardingProvider auto-opens ChoosePathWizard, which
AppShell mounts on every authenticated page as a modal overlay that
intercepts pointer events. Every page.click then timed out on a dialog the
spec never mentions. The mocks now satisfy all three conditions.

Result: 60/60 across all four viewports, green for the first time. Screenshot
byte sizes differ per viewport (286K/375K/689K/720K), confirming the run
captured four distinct renders rather than the same page four times.

Also adds the maxExpiryMs env-override and invalid-value coverage that every
sibling PolicyLimits field already had.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Ten local reviewers ran against the rebased branch. No CRITICAL. One HIGH,
fixed here. Every remaining MEDIUM is either fixed or tracked.

HIGH — the DO migration-safety gate was green for the wrong reason.
`extractSqlFromTypeScript` matched only backtick and single-quoted strings, so
migration 034's second statement — double-quoted precisely BECAUSE it embeds
`DEFAULT 'always'` — was never handed to the danger checks at all. The file
reported PASS without that statement being inspected. Reproduced directly: a
`sql.exec("DROP TABLE project_policies")` is invisible to the scanner. Rule 31
calls this gate one that "cannot be bypassed", so a quoting style must not be
able to bypass it. The extractor now covers all three string forms, each pattern
anchored to its own delimiter so a quote inside one string cannot pair with a
delimiter from a neighbouring statement.

Proven discriminating: against the pre-fix two-pattern extractor exactly the four
double-quote tests go red and the seven pre-existing ones stay green.

Also from review:

- SAM-session `add_policy` had two of the edge cases MCP and REST each cover for
  their own parser. Added past-expiry, beyond-horizon, unknown-scope and
  non-numeric-expiry, all asserting the write does NOT happen. Proven
  discriminating against both the shared validator and the inline guards.
- MCP `update_policy` lacked the I/O-budget regression the REST route has, on the
  path agents actually call. Added; proven discriminating by making the pre-read
  unconditional.
- The Playwright worst case was split across two cards: `pl4` carried the 200-char
  title but was not expired, so "longest title" and "most badges" never met. `pl4`
  is now the real worst case and both viewports assert the badges on that card
  (page-wide would now be a strict-mode violation, not a pass). 60/60 pass;
  screenshots opened and checked.
- Public `architecture/overview.md` documents `formatPolicyDirectives()` output
  and did not mention the annotation; added, with the read-time-filter semantics.
  Added the `CLAUDE.md` Recent Changes entry (rule 01, same PR).
- The task file's writer enumeration listed two of three writers. Recorded the
  third and the near-miss it caused.

Not fixed, tracked instead: expired rows are deliberately retained and no longer
count toward the cap, so nothing bounds total row growth. A naive total ceiling is
worse than the growth — `removePolicy` is a soft delete, so a project that hit the
ceiling could never write a policy again. Documented at the cap site and tracked in
tasks/backlog/2026-08-23-policy-row-retention-bound.md (rule 42).

Co-Authored-By: Claude <noreply@anthropic.com>
CI's `quality:ast-checks` (not part of `check:fast`, which is why my local run
was green) failed with three errors, all mine:

  sql-injection    policies.ts:71  — template literal with ${APPLIES_NOW_SQL}
  sql-injection    policies.ts:216 — same
  parameterized-sql policies.ts:216 — 1 placeholder but 2 parameters

The interpolated value is a module constant, so there was no injection risk —
but the rule is right that a statement assembled at the `sql.exec()` call site
cannot be checked, and the placeholder miscount is the direct consequence: the
checker cannot see the `?` hidden inside the interpolated constant, so it read
`LIMIT ?` as the only placeholder against two bound parameters.

Compose both statements once at module scope instead. The string handed to
`sql.exec()` is now a fixed load-time constant with no call-site interpolation,
every executed statement is greppable in one place, and the predicate stays
single-sourced so the cap count and the injection read cannot drift apart.

Behaviour is unchanged, and I re-proved the discriminating property against the
restructured code rather than assuming it survived: replacing the expiry
conjunct with a tautology still turns exactly the same two real-DO tests red
("excludes an expired policy…", "excludes expired policies from the per-project
cap"), with the null-expiry control still green. Restored, 17/17.

`quality:ast-checks` now reports 0 errors (240 pre-existing throw-without-log
warnings unchanged). Also ran the rest of that CI job locally — file-sizes,
stale-artifacts, migration-safety, wrangler-bindings all pass.

Co-Authored-By: Claude <noreply@anthropic.com>
@simple-agent-manager
simple-agent-manager Bot force-pushed the sam/continue-complete-policy-lifecycle-e3tm8a branch from d836ae7 to 3508d70 Compare August 24, 2026 08:28
raphaeltm and others added 2 commits August 24, 2026 08:38
Move PolicyEntry, formatPolicyLifecycle, formatPolicyDirectives, and
buildPolicyInstructions from instruction-tools.ts (820 lines) to
instruction-formatting.ts (133 lines), bringing instruction-tools.ts
to 699 lines — well under the 800-line hard limit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…sort

The previous commit removed the policy formatting helpers from
instruction-tools.ts but did not include the new
instruction-formatting.ts file they were extracted to.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@simple-agent-manager
simple-agent-manager Bot merged commit 85f3ac8 into main Aug 24, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant